Skip to Content

1. 核心思想:信号量是什么?

想象一下一个图书馆有几间自习室,每间自习室只能容纳一个人。门口有一个管理员,他手里拿着一串钥匙,钥匙的数量正好等于自习室的数量。

  • 想进自习室的学生(进程/线程):必须先问管理员要一把钥匙。
    • 如果还有钥匙,管理员给你一把,你进去学习。
    • 如果没有钥匙了,你必须在门口排队等待。
  • 离开自习室的学生(进程/线程):出来后必须把钥匙还给管理员。
    • 管理员收回钥匙后,会通知在门口排队的第一个学生,把这把钥匙给他,让他进去。

在这个比喻中:

  • 自习室:就是临界资源(Critical Resource),比如共享内存、打印机等。
  • 学生:就是需要访问这些资源的进程或线程
  • 钥匙:就是信号量
  • 钥匙的数量:就是信号量的,表示可用资源的数量。
  • 问管理员要钥匙:就是执行P操作(wait)
  • 还钥匙给管理员:就是执行V操作(signal)
  • 在门口排队:就是进程 阻塞(Block) 并进入等待队列。

一句话总结:信号量是一个特殊的整数变量,用于在多个进程/线程之间协调对共享资源的访问,以避免竞争条件(Race Condition)和实现有序协作。


2. 工作原理:P、V操作

信号量的所有魔力都蕴含在两个 原子操作(Atomic Operations) 中。原子操作意味着这个操作是不可中断的,一旦开始,就必须执行到底,中间不会被其他任何操作打断。

一个信号量 S 通常包含两部分:

  • 一个整数值 S.value
  • 一个等待队列 S.queue,用于存放被阻塞的进程。

P操作 (Proberen,荷兰语“尝试”)

也称为 wait(), down()acquire()

P(S) { S.value--; // 尝试获取一个资源 if (S.value < 0) { // 资源不足,已经出现"欠账"了 // 将当前进程/线程放入等待队列 S.queue block(); // 阻塞自己 } // 如果 S.value >= 0,则说明资源可用,继续执行 }

关键点S.value 的值可以为负数。负数的绝对值表示当前有多少个进程正在等待这个资源。例如,S.value = -2 意味着资源已经被用完,并且有2个进程在排队等待。

V操作 (Verhogen,荷兰语“增加”)

也称为 signal(), up()release()

V(S) { S.value++; // 释放一个资源 if (S.value <= 0) { // 如果 S.value <= 0,说明之前有进程在等待 // 从等待队列 S.queue 中唤醒一个进程 wakeup(P); // 唤醒一个进程 } // 如果 S.value > 0,说明没有进程在等待,只是资源数增加了 }

关键点V操作不仅增加了资源计数,更重要的是,它可能会唤醒一个正在等待的进程,这是实现同步的关键。

为什么必须是原子操作? 想象一下,如果P操作不是原子的:

  1. 进程A检查 S.value > 0,发现为真。
  2. 此时发生上下文切换,进程B也检查 S.value > 0,发现也为真。
  3. 进程A和B都以为自己拿到了资源,都进入了临界区。 这样就失去了保护作用,所以P和V操作必须由操作系统内核通过特殊指令(如关中断、test-and-set指令)来保证其原子性。

3. 分类:计数型与二值信号量

计数型信号量 (Counting Semaphore)

  • S.value 的取值可以是任意非负整数(或者在P操作后可以为负数)。
  • 用于表示有多个实例的资源。
  • 例子:我们开头的图书馆自习室(有多间),或者一个拥有N个缓冲区的生产者-消费者模型中的empty(空闲缓冲区)信号量。

二值信号量 (Binary Semaphore)

  • S.value 的取值只能是 0 或 1
  • 它本质上就是一个锁(Lock),用于实现互斥(Mutual Exclusion)。当资源只有一个实例时使用。
  • 初始值为1,表示资源可用;被获取后变为0,表示资源被占用。

4. 经典应用场景

信号量非常灵活,可以解决两类核心的并发问题:

a. 实现互斥 (Mutual Exclusion)

这是最常见的用途,使用一个二值信号量(通常称为 mutex)。

  • 初始化Semaphore mutex = 1; (表示资源可用)
  • 访问临界区代码
    P(mutex); // 请求进入,如果有人在用就等待 // --- 临界区开始 --- // (访问共享资源的代码) // --- 临界区结束 --- V(mutex); // 释放资源,通知别人可以使用

这样就保证了在任何时刻,只有一个进程能进入临界区。

b. 实现同步 (Synchronization)

同步是指让多个进程按照预定的顺序执行,一个进程的执行需要等待另一个进程完成某项任务。

经典案例:生产者-消费者问题

  • 问题描述:一个或多个生产者进程生产数据放入一个固定大小的缓冲区,一个或多个消费者进程从缓冲区取出数据消费。

  • 需要解决的问题

    1. 缓冲区满时,生产者不能再放数据(需要等待)。
    2. 缓冲区空时,消费者不能再取数据(需要等待)。
    3. 生产者和消费者不能同时操作缓冲区(互斥)。
  • 解决方案:使用三个信号量

    1. Semaphore mutex = 1; // 用于互斥访问缓冲区
    2. Semaphore empty = N; // 计数型,表示空闲缓冲区的数量,N是缓冲区总大小
    3. Semaphore full = 0; // 计数型,表示已填充缓冲区的数量

    生产者代码:

    while(true) { produce_item(); P(empty); // 等待有空位 P(mutex); // 锁住缓冲区 add_to_buffer(); V(mutex); // 解锁缓冲区 V(full); // 通知消费者,多了一个产品 }

    消费者代码:

    while(true) { P(full); // 等待有产品 P(mutex); // 锁住缓冲区 remove_from_buffer(); V(mutex); // 解锁缓冲区 V(empty); // 通知生产者,多了一个空位 consume_item(); }

在这个例子中,emptyfull 信号量完美地实现了生产者和消费者之间的同步关系


5. 信号量 vs. 互斥锁 (Mutex)

这是一个非常重要的区别,尤其是在面试中。

特性互斥锁 (Mutex)信号量 (Semaphore)
核心概念所有权(Ownership)信号/通知(Signaling)
工作模式只有加锁的线程才能解锁一个线程可以P(等待),而另一个线程可以V(通知)。
用途主要用于互斥,保护临界区。可用于互斥,更常用于复杂的同步场景。
值的范围通常是锁住/未锁住两种状态(类似二值信号量)。可以是任意非负整数(计数型信号量)。
类比厕所的门锁,谁进去锁上,就必须由他自己出来时打开。图书馆自习室的钥匙,你用完还给管理员,管理员可以把钥匙给任何一个在等待的人。

简单来说:如果你只是想保护一段代码不被并发执行,用互斥锁更简单、更安全。如果你需要实现类似生产者-消费者这种复杂的“等待-通知”协作模式,信号量是更好的工具。


6. 优缺点与风险

优点

  • 功能强大:既能实现互斥,也能实现复杂的同步。
  • 历史悠久:经过了充分的理论和实践检验,是并发编程的基石。

缺点与风险

  • 编程复杂,易出错PV操作必须成对出现,如果忘记V操作,会导致资源永久锁死(死锁);如果忘记P操作,则无法保护临界区。
  • 死锁(Deadlock):两个或多个进程因互相持有对方需要的资源而无限期等待。例如,进程A持有信号量S1并等待S2,进程B持有S2并等待S1。
  • 优先级反转(Priority Inversion):一个低优先级的进程持有了一个高优先级进程所需的信号量,导致高优先级进程被阻塞,而低优先级进程又被一个中等优先级的进程抢占CPU,使得高优先级进程迟迟无法执行。

7. 一个简单的伪代码示例

假设我们要用信号量保护一个全局变量 global_counter 的并发访问。

// 初始化 Semaphore mutex = 1; int global_counter = 0; // 线程1 的函数 void thread1_function() { for (int i = 0; i < 10000; i++) { P(mutex); // 获取锁 global_counter++; // 临界区 V(mutex); // 释放锁 } } // 线程2 的函数 void thread2_function() { for (int i = 0; i < 10000; i++) { P(mutex); // 获取锁 global_counter++; // 临界区 V(mutex); // 释放锁 } } // 主函数 main() { create_thread(thread1_function); create_thread(thread2_function); join_threads(); // 等待所有线程结束 print(global_counter); // 最终结果应该是 20000 }

如果没有信号量的保护,global_counter++ 这个操作(实际上包含读-改-写三步)会被并发执行的线程打断,最终结果会小于 20000。

希望这份详细的解释能帮助你彻底理解操作系统中的信号量!

Last updated on